iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app系列 第 15

Android 表單元件實戰——RadioButton、CheckBox 與 BMI 計算器

  • 分享至 

  • xImage
  •  

Day 15:Android 表單元件實戰——RadioButton、CheckBox 與 BMI 計算器

今天要正式加入可以操作的表單元件,練習使用 TextInputEditText 接收文字與數字、使用 RadioButton 選擇單一項目、使用 CheckBox 選擇多個項目,最後完成一個會計算 BMI 並顯示對應圖片

今天會完成以下內容:

  • 認識 Android Studio Layout Editor。
  • 使用 RadioGroupRadioButton 建立單選題。
  • 使用 CheckBox 建立複選題。
  • 使用 TextInputEditText 接收姓名、年齡、身高與體重。
  • 使用 Toast 顯示輸入錯誤訊息。
  • 使用 toDoubleOrNull() 安全轉換數字。
  • 計算 BMI 並判斷體位。
  • 根據性別及 BMI 顯示不同圖片。
  • 使用 setCompoundDrawables() 在文字旁顯示圖片。
  • 排查新版 Android Emulator 的相容性問題。

參考資料:Android Developers-RadioButtonAndroid Developers-CheckBox

Yes

一、先確認 Android 模擬器可以正常執行

Android Emulator 可以模擬不同型號、螢幕尺寸與 Android 版本的裝置,讓我們不需要準備每一種實體手機,也能測試 App。參考資料:Android Developers-Android Emulator

這次使用 Android Studio 內建的 Pixel 6 模擬器。下圖是模擬器已經成功啟動,但 Gradle 還在同步的狀態。

Android Studio 啟動 Pixel 6 模擬器

畫面上方顯示:

Gradle project sync in progress...

此時要等待 Gradle 同步與專案建置完成,不要連續按下執行按鈕。Gradle 同步完成後,Android Studio 才能正確解析相依套件、SDK 版本與專案設定。參考資料:Android Developers-Build your app

成功執行後,Build Output 會顯示:

BUILD SUCCESSFUL

模擬器中則會顯示 App 的畫面:

Android App 成功顯示 Hello World

看到 BUILD SUCCESSFUL 代表專案已成功編譯,但仍要觀察模擬器畫面是否符合預期;「成功建置」和「功能完全正確」是兩個不同層次的檢查。參考資料:Android Developers-Build and run your app


二、認識 Android Studio Layout Editor

Android 傳統 View 系統通常使用 XML 描述畫面。開啟 res/layout/activity_main.xml 後,可以使用 Android Studio 的 Layout Editor,以拖放方式加入 TextViewButtonLinearLayoutImageView 等元件。參考資料:Android Developers-Layout Editor

使用 Layout Editor 設計 Android 畫面

Layout Editor 主要分為以下區域:

區域 功能
Palette 提供可以加入畫面的 UI 元件
Component Tree 顯示目前畫面的元件階層
Design 顯示接近實際 App 的預覽
Blueprint 顯示 Constraint 與元件邊界
Attributes 設定元件 ID、尺寸、文字及背景
Project 顯示 Kotlin、XML、Drawable 等專案檔案

參考資料:Android Developers-Layout Editor overview


三、使用 LinearLayout 排列元件

參考畫面在 ConstraintLayout 中加入一個水平 LinearLayout,準備將多個選項水平排列。LinearLayout 會依照 orientation 指定的方向,逐一排列內部元件。參考資料:Android Developers-LinearLayout

設定 LinearLayout 的寬度、高度與方向

水平排列:

android:orientation="horizontal"

垂直排列:

android:orientation="vertical"

如果 LinearLayout 放在 ConstraintLayout 中,設定 layout_width="0dp" 通常代表寬度由左右 Constraint 決定;如果沒有設定完整 Constraint,元件可能在執行時移位。參考資料:Android Developers-ConstraintLayout

<LinearLayout
    android:id="@+id/linearLayoutOptions"
    android:layout_width="0dp"
    android:layout_height="80dp"
    android:orientation="horizontal"
    android:gravity="center"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toBottomOf="@id/textViewTitle">

    <!-- 選項元件放在這裡 -->

</LinearLayout>

四、RadioButton:一次只能選擇一個選項

RadioButton 適合用於互斥選項,例如性別、付款方式或圖片種類。多個 RadioButton 應放在同一個 RadioGroup 中,Android 才能自動確保一次只選中一個項目。參考資料:Android Developers-Radio buttons

<RadioGroup
    android:id="@+id/radioGroup_Flower"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <RadioButton
        android:id="@+id/radioButton_f1"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="第一張花朵" />

    <RadioButton
        android:id="@+id/radioButton_f2"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="第二張花朵" />

    <RadioButton
        android:id="@+id/radioButton_f3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="第三張花朵" />

</RadioGroup>

RadioGroup 可以透過 checkedRadioButtonId 取得目前被選中的 RadioButton ID。若尚未選擇任何項目,結果會是 View.NO_ID。參考資料:Android Developers-RadioGroup

when (radioGroupFlower.checkedRadioButtonId) {

    R.id.radioButton_f1 -> {
        textViewResult.text = radioButton1.text
    }

    R.id.radioButton_f2 -> {
        textViewResult.text = radioButton2.text
    }

    R.id.radioButton_f3 -> {
        textViewResult.text = radioButton3.text
    }

    else -> {
        textViewResult.text = "尚未選擇花朵"
    }
}

五、CheckBox:可以同時選擇多個項目

CheckBox 適合用於彼此獨立的複選項目,例如興趣、配料、功能或同意事項。它與 RadioButton 不同,同一畫面中的多個 CheckBox 可以同時勾選。參考資料:Android Developers-Checkboxes

<CheckBox
    android:id="@+id/checkBox_1"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="紅色" />

<CheckBox
    android:id="@+id/checkBox_2"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="黃色" />

<CheckBox
    android:id="@+id/checkBox_3"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="藍色" />

Kotlin 可以透過 isChecked 判斷 CheckBox 是否被選中:

if (checkBox1.isChecked) {
    textViewResult.append(checkBox1.text)
}

由於每個 CheckBox 都具有自己的勾選狀態,所以需要分別檢查。參考資料:Android Developers-CheckBox API


六、範例一:RadioButton 與 CheckBox 綜合練習

第一個完整範例會先讀取使用者選擇的花朵,再將所有勾選的附加選項顯示在 TextView。參考資料:Android Developers-Input controls

MainActivity 完整程式

package com.example.widget_10b

import android.os.Bundle
import android.widget.Button
import android.widget.CheckBox
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat

class MainActivity : AppCompatActivity() {

    // 顯示使用者選擇結果的 TextView。
    private lateinit var textViewResult: TextView

    // 按下後讀取所有選項。
    private lateinit var buttonOK: Button

    // 三個可以同時選取的 CheckBox。
    private lateinit var checkBox1: CheckBox
    private lateinit var checkBox2: CheckBox
    private lateinit var checkBox3: CheckBox

    // 三個只能選擇一個的 RadioButton。
    private lateinit var radioButton1: RadioButton
    private lateinit var radioButton2: RadioButton
    private lateinit var radioButton3: RadioButton

    // 管理三個互斥 RadioButton 的 RadioGroup。
    private lateinit var radioGroupFlower: RadioGroup

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 啟用 Edge-to-Edge 顯示。
        enableEdgeToEdge()

        // 載入 activity_main.xml。
        setContentView(R.layout.activity_main)

        // 加入系統狀態列與導覽列的安全邊距,
        // 避免畫面內容被系統列遮住。
        ViewCompat.setOnApplyWindowInsetsListener(
            findViewById(R.id.main)
        ) { view, insets ->

            val systemBars =
                insets.getInsets(
                    WindowInsetsCompat.Type.systemBars()
                )

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 取得 RadioGroup。
        radioGroupFlower =
            findViewById(R.id.radioGroup_Flower)

        // 取得三個 RadioButton。
        radioButton1 =
            findViewById(R.id.radioButton_f1)

        radioButton2 =
            findViewById(R.id.radioButton_f2)

        radioButton3 =
            findViewById(R.id.radioButton_f3)

        // 取得三個 CheckBox。
        checkBox1 =
            findViewById(R.id.checkBox_1)

        checkBox2 =
            findViewById(R.id.checkBox_2)

        checkBox3 =
            findViewById(R.id.checkBox_3)

        // 取得確認按鈕與結果 TextView。
        buttonOK =
            findViewById(R.id.button)

        textViewResult =
            findViewById(R.id.textView)

        // 清除 TextView 的預設文字。
        textViewResult.text = ""

        buttonOK.setOnClickListener {

            // 使用 StringBuilder 組合完整結果,
            // 避免反覆直接修改 TextView。
            val result = StringBuilder()

            // 判斷 RadioGroup 中被選擇的 RadioButton。
            when (radioGroupFlower.checkedRadioButtonId) {

                R.id.radioButton_f1 -> {
                    result.append(radioButton1.text)
                }

                R.id.radioButton_f2 -> {
                    result.append(radioButton2.text)
                }

                R.id.radioButton_f3 -> {
                    result.append(radioButton3.text)
                }

                else -> {
                    result.append("尚未選擇花朵")
                }
            }

            result.append("\n")

            // 使用集合保存所有勾選的 CheckBox 文字。
            val checkedItems = mutableListOf<String>()

            if (checkBox1.isChecked) {
                checkedItems.add(
                    checkBox1.text.toString()
                )
            }

            if (checkBox2.isChecked) {
                checkedItems.add(
                    checkBox2.text.toString()
                )
            }

            if (checkBox3.isChecked) {
                checkedItems.add(
                    checkBox3.text.toString()
                )
            }

            // joinToString() 可以避免最後多出一個逗號。
            if (checkedItems.isEmpty()) {
                result.append("未勾選其他項目")
            } else {
                result.append(
                    checkedItems.joinToString(", ")
                )
            }

            // 一次將完整結果顯示到畫面上。
            textViewResult.text = result.toString()
        }
    }
}

參考資料:Android Developers-RadioGroupAndroid Developers-CheckBox


七、為什麼改用 StringBuilder?

原始參考程式使用多次 append()

textViewResult.append("\n")

if (checkBox1.isChecked) {
    textViewResult.append("${checkBox1.text} ,")
}

if (checkBox2.isChecked) {
    textViewResult.append("${checkBox2.text} ,")
}

if (checkBox3.isChecked) {
    textViewResult.append("${checkBox3.text} ,")
}

這樣能正常顯示結果,但最後一個項目後方仍會留下逗號。改用清單及 joinToString(", "),可以產生格式較乾淨的結果。參考資料:Kotlin-joinToString

val checkedItems = mutableListOf<String>()

if (checkBox1.isChecked) {
    checkedItems.add(checkBox1.text.toString())
}

if (checkBox2.isChecked) {
    checkedItems.add(checkBox2.text.toString())
}

if (checkBox3.isChecked) {
    checkedItems.add(checkBox3.text.toString())
}

textViewResult.append(
    checkedItems.joinToString(", ")
)

八、TextInputEditText:接收使用者輸入

BMI App 需要輸入姓名、年齡、身高與體重,因此使用 Material Components 的 TextInputEditText。它通常放在 TextInputLayout 裡,能提供浮動提示、錯誤顯示與較完整的無障礙支援。參考資料:Android Developers-TextInputEditText

<com.google.android.material.textfield.TextInputLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="姓名">

    <com.google.android.material.textfield.TextInputEditText
        android:id="@+id/etName"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:inputType="textPersonName" />

</com.google.android.material.textfield.TextInputLayout>

數字欄位應設定適合的 inputType,讓手機顯示相對應的數字鍵盤。身高與體重可能包含小數,因此可以使用 numberDecimal。參考資料:Android Developers-Specify the input method type

<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/etHeight"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="身高(公分)"
    android:inputType="numberDecimal" />

<com.google.android.material.textfield.TextInputEditText
    android:id="@+id/etWeight"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="體重(公斤)"
    android:inputType="numberDecimal" />

九、BMI 的計算公式

BMI 中文稱為身體質量指數,計算方式是體重公斤數除以身高公尺數的平方。參考資料:衛生福利部國民健康署-成人健康體位標準

BMI = 體重(公斤)÷ 身高(公尺)²

使用者輸入的身高單位是公分,所以程式必須先除以 100,轉換成公尺。參考資料:衛生福利部國民健康署-成人健康體重對照表

private fun calculateBMI(
    height: Double,
    weight: Double
): Double {

    // 將公分換算成公尺。
    val heightInMeters = height / 100

    // BMI=體重÷身高平方。
    return weight /
        (heightInMeters * heightInMeters)
}

例如身高 168 公分、體重 79.2 公斤:

身高:168 ÷ 100 = 1.68 公尺

BMI:79.2 ÷ (1.68 × 1.68)
   ≈ 28.06

依台灣成人體位標準,此結果屬於 BMI 大於等於 27 的肥胖範圍。BMI 只能作為成人健康體位的初步參考,不能單獨作為疾病診斷。參考資料:衛生福利部國民健康署-成人健康體位標準


十、台灣成人 BMI 分類

參考程式使用的是台灣成人體位標準。參考資料:衛生福利部國民健康署-BMI 維持 18~24

BMI 範圍 體位分類
BMI < 18.5 過輕
18.5 ≤ BMI < 24 正常
24 ≤ BMI < 27 過重
BMI ≥ 27 肥胖

程式可使用沒有參數的 when,依條件由上往下判斷。參考資料:Kotlin-when expression

private fun getBMICategory(
    bmi: Double
): String {

    return when {
        bmi < 18.5 -> "過輕"
        bmi < 24.0 -> "正常"
        bmi < 27.0 -> "過重"
        else -> "肥胖"
    }
}

十一、BMI 圖片素材

參考資料準備了四張不同體位的圖片,可以依 BMI 結果顯示對應圖像。

體位 圖片
肥胖 fat_1.jpg
過重 fat_2.jpg
過輕 fat_3.png
正常 fat_4.jpg

肥胖體位示意圖
過重體位示意圖
過輕體位示意圖
正常體位示意圖

在 Android 專案中,圖片應放入:

app/src/main/res/drawable/

Drawable 資源檔名只能使用小寫英文字母、數字與底線,不能使用空白、連字號或大寫字母。參考資料:Android Developers-Drawable resources


十二、BMI 計算器完整 Kotlin 程式

以下依照上傳的 MainActivity(20260815-010725).kt 整理,保留所有功能並加強欄位驗證、性別檢查、命名與註解。參考資料:Android Developers-Text fields

package com.example.ex2

import android.graphics.drawable.Drawable
import android.os.Bundle
import android.widget.Button
import android.widget.RadioButton
import android.widget.RadioGroup
import android.widget.TextView
import android.widget.Toast
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import com.google.android.material.textfield.TextInputEditText

class MainActivity : AppCompatActivity() {

    // 姓名輸入欄位。
    private lateinit var editTextName: TextInputEditText

    // 年齡輸入欄位。
    private lateinit var editTextAge: TextInputEditText

    // 性別單選群組。
    private lateinit var radioGroupGender: RadioGroup

    // 男性與女性選項。
    private lateinit var radioButtonMale: RadioButton
    private lateinit var radioButtonFemale: RadioButton

    // 身高與體重輸入欄位。
    private lateinit var editTextHeight: TextInputEditText
    private lateinit var editTextWeight: TextInputEditText

    // 清除與計算按鈕。
    private lateinit var buttonCancel: Button
    private lateinit var buttonBMI: Button

    // 顯示個人資料、BMI 與體位結果。
    private lateinit var textViewResult: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // 讓畫面延伸至系統狀態列及導覽列區域。
        enableEdgeToEdge()

        // 載入 activity_main.xml。
        setContentView(R.layout.activity_main)

        // 取得系統列範圍並設定 Padding,
        // 避免表單內容被狀態列或導覽列遮住。
        ViewCompat.setOnApplyWindowInsetsListener(
            findViewById(R.id.main)
        ) { view, insets ->

            val systemBars =
                insets.getInsets(
                    WindowInsetsCompat.Type.systemBars()
                )

            view.setPadding(
                systemBars.left,
                systemBars.top,
                systemBars.right,
                systemBars.bottom
            )

            insets
        }

        // 初始化所有 XML 畫面元件。
        initViews()

        // 設定兩個按鈕的點擊事件。
        setupClickListeners()
    }

    /**
     * 使用 findViewById() 取得 activity_main.xml 中的元件。
     */
    private fun initViews() {

        editTextName =
            findViewById(R.id.etName)

        editTextAge =
            findViewById(R.id.etAge)

        radioGroupGender =
            findViewById(R.id.rgGender)

        radioButtonMale =
            findViewById(R.id.rbMale)

        radioButtonFemale =
            findViewById(R.id.rbFemale)

        editTextHeight =
            findViewById(R.id.etHeight)

        editTextWeight =
            findViewById(R.id.etWeight)

        buttonCancel =
            findViewById(R.id.btnCancel)

        buttonBMI =
            findViewById(R.id.btnBMI)

        textViewResult =
            findViewById(R.id.tvResult)
    }

    /**
     * 設定清除按鈕與 BMI 計算按鈕。
     */
    private fun setupClickListeners() {

        buttonCancel.setOnClickListener {

            // 清除所有文字輸入欄位。
            editTextName.text?.clear()
            editTextAge.text?.clear()
            editTextHeight.text?.clear()
            editTextWeight.text?.clear()

            // 清除 RadioGroup 的性別選擇。
            radioGroupGender.clearCheck()

            // 清除 BMI 結果文字。
            textViewResult.text = ""

            // 清除 TextView 四個方向的圖片。
            textViewResult.setCompoundDrawables(
                null,
                null,
                null,
                null
            )

            // 將輸入焦點移回姓名欄位。
            editTextName.requestFocus()
        }

        buttonBMI.setOnClickListener {

            // 讀取所有輸入內容並移除頭尾空白。
            val nameString =
                editTextName.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            val ageString =
                editTextAge.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            val heightString =
                editTextHeight.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            val weightString =
                editTextWeight.text
                    ?.toString()
                    ?.trim()
                    .orEmpty()

            // 檢查是否有空白欄位。
            if (
                nameString.isEmpty() ||
                ageString.isEmpty() ||
                heightString.isEmpty() ||
                weightString.isEmpty()
            ) {
                showToast("請輸入所有欄位")
                return@setOnClickListener
            }

            // 年齡應該是有效的正整數。
            val ageValue =
                ageString.toIntOrNull()

            if (ageValue == null || ageValue <= 0) {
                showToast("年齡格式錯誤")
                return@setOnClickListener
            }

            // 身高與體重允許輸入小數,
            // 使用 toDoubleOrNull() 避免格式錯誤造成閃退。
            val heightValue =
                heightString.toDoubleOrNull()

            val weightValue =
                weightString.toDoubleOrNull()

            if (
                heightValue == null ||
                heightValue <= 0 ||
                weightValue == null ||
                weightValue <= 0
            ) {
                showToast("身高或體重格式錯誤")
                return@setOnClickListener
            }

            // 確認使用者已選擇性別。
            if (
                radioGroupGender.checkedRadioButtonId ==
                -1
            ) {
                showToast("請選擇性別")
                return@setOnClickListener
            }

            // 計算 BMI。
            val bmi =
                calculateBMI(
                    height = heightValue,
                    weight = weightValue
                )

            // 取得 BMI 分類結果。
            val bmiCategory =
                getBMICategory(bmi)

            // 取得目前選擇的性別文字。
            val gender =
                when (
                    radioGroupGender
                        .checkedRadioButtonId
                ) {
                    R.id.rbMale -> "男"
                    R.id.rbFemale -> "女"
                    else -> "未指定"
                }

            // 顯示個人資料與 BMI 計算結果。
            textViewResult.text =
                """
                姓名:$nameString
                年齡:$ageValue
                性別:$gender
                身高:$heightValue 公分
                體重:$weightValue 公斤
                BMI:${String.format("%.2f", bmi)}
                BMI 結果:$bmiCategory
                """.trimIndent()

            // 根據性別及 BMI 顯示對應圖片。
            updateResultDrawable(bmi)
        }
    }

    /**
     * 計算 BMI。
     *
     * @param height 身高,單位為公分
     * @param weight 體重,單位為公斤
     * @return BMI 計算結果
     */
    private fun calculateBMI(
        height: Double,
        weight: Double
    ): Double {

        // 將身高由公分轉換成公尺。
        val heightInMeters = height / 100.0

        // BMI=體重÷身高平方。
        return weight /
            (heightInMeters * heightInMeters)
    }

    /**
     * 依照台灣成人健康體位標準分類 BMI。
     */
    private fun getBMICategory(
        bmi: Double
    ): String {

        return when {
            bmi < 18.5 -> "過輕"
            bmi < 24.0 -> "正常"
            bmi < 27.0 -> "過重"
            else -> "肥胖"
        }
    }

    /**
     * 根據性別與 BMI 選擇結果圖片,
     * 再顯示於 TextView 左側。
     */
    private fun updateResultDrawable(
        bmi: Double
    ) {

        val imageResource =
            if (radioButtonMale.isChecked) {

                when {
                    bmi < 18.5 ->
                        R.drawable.men_fat3

                    bmi < 24.0 ->
                        R.drawable.men_fat4

                    bmi < 27.0 ->
                        R.drawable.men_fat2

                    else ->
                        R.drawable.men_fat1
                }

            } else if (
                radioButtonFemale.isChecked
            ) {

                when {
                    bmi < 18.5 ->
                        R.drawable.woman_fat3

                    bmi < 24.0 ->
                        R.drawable.woman_fat4

                    bmi < 27.0 ->
                        R.drawable.woman_fat2

                    else ->
                        R.drawable.woman_fat1
                }

            } else {
                null
            }

        if (imageResource != null) {

            // 將 Drawable 資源 ID 轉換成 Drawable。
            val drawable: Drawable? =
                ContextCompat.getDrawable(
                    this,
                    imageResource
                )

            // 將 120dp 轉換成目前裝置使用的像素值。
            val imageSize =
                (
                    120 *
                    resources.displayMetrics.density
                ).toInt()

            // Compound Drawable 必須先設定顯示邊界。
            drawable?.setBounds(
                0,
                0,
                imageSize,
                imageSize
            )

            // 在 TextView 左側顯示圖片。
            textViewResult.setCompoundDrawables(
                drawable,
                null,
                null,
                null
            )

            // 增加圖片與文字之間的距離。
            textViewResult.compoundDrawablePadding =
                (
                    12 *
                    resources.displayMetrics.density
                ).toInt()

        } else {

            // 沒有選擇性別時清除圖片。
            textViewResult.setCompoundDrawables(
                null,
                null,
                null,
                null
            )
        }
    }

    /**
     * 集中顯示短時間 Toast 訊息。
     */
    private fun showToast(
        message: String
    ) {
        Toast.makeText(
            this,
            message,
            Toast.LENGTH_SHORT
        ).show()
    }
}

參考資料:Android Developers-TextInputEditTextAndroid Developers-Toast


十三、為什麼使用 toDoubleOrNull?

如果直接呼叫:

val height = heightString.toDouble()

當使用者輸入英文字母、空字串或錯誤格式時,程式會產生 NumberFormatException。使用 toDoubleOrNull() 時,無法轉換就會回傳 null,程式可以自行顯示錯誤訊息。參考資料:Kotlin-toDoubleOrNull

val height = heightString.toDoubleOrNull()

if (height == null || height <= 0) {
    Toast.makeText(
        this,
        "身高格式錯誤",
        Toast.LENGTH_SHORT
    ).show()

    return@setOnClickListener
}

這種寫法可以避免 App 因為使用者輸入錯誤而直接閃退。輸入資料永遠不應被程式預設為正確,特別是數字、帳號、密碼與網路傳回的資料。參考資料:Android Developers-Improve your app’s reliability


十四、使用 Toast 顯示錯誤訊息

Toast 是短暫出現在畫面上的提示訊息,適合顯示「請輸入所有欄位」或「身高格式錯誤」等簡短通知。參考資料:Android Developers-Toasts

Toast.makeText(
    this,
    "請輸入所有欄位",
    Toast.LENGTH_SHORT
).show()

三個參數分別代表:

參數 說明
this 顯示 Toast 使用的 Context
"請輸入所有欄位" 要顯示的訊息
Toast.LENGTH_SHORT 顯示時間

錯誤若只對應某一個輸入欄位,實務上也可以使用 TextInputLayout.error,讓錯誤訊息直接顯示在欄位附近,比單獨顯示 Toast 更容易理解。參考資料:Android Developers-TextInputLayout


十五、在 TextView 旁顯示圖片

TextView 除了文字之外,還能在左、上、右、下四個方向設定 Compound Drawable。參考程式將 BMI 圖片顯示在結果文字左側。參考資料:Android Developers-TextView.setCompoundDrawables

textViewResult.setCompoundDrawables(
    drawable, // 左
    null,     // 上
    null,     // 右
    null      // 下
)

使用 setCompoundDrawables() 時,需要先呼叫 setBounds() 設定 Drawable 的顯示尺寸:

drawable?.setBounds(
    0,
    0,
    imageSize,
    imageSize
)

如果不想自行設定 Bounds,也可以改用 setCompoundDrawablesWithIntrinsicBounds(),讓 Drawable 使用原始尺寸;但圖片原始大小不一致時,畫面容易出現尺寸差異。參考資料:Android Developers-TextView API


十六、dp 為什麼要轉換成 px?

Android XML 通常使用 dp 指定元件尺寸,但 Drawable.setBounds() 接收的是像素。因此程式使用螢幕密度將 120dp 轉換為實際像素。參考資料:Android Developers-Support different pixel densities

val imageSize =
    (
        120 *
        resources.displayMetrics.density
    ).toInt()

不同裝置的像素密度不一樣,直接使用固定的 120px,可能在高密度手機上看起來很小;使用 dp 換算後,圖片的視覺尺寸會比較一致。參考資料:Android Developers-Density independence


十七、清除按鈕要重設哪些資料?

BMI App 的清除按鈕不只要清空 TextInputEditText,還要取消 RadioGroup 選擇、清除結果文字與圖片。參考資料:Android Developers-RadioGroup.clearCheck

buttonCancel.setOnClickListener {

    editTextName.text?.clear()
    editTextAge.text?.clear()
    editTextHeight.text?.clear()
    editTextWeight.text?.clear()

    radioGroupGender.clearCheck()

    textViewResult.text = ""

    textViewResult.setCompoundDrawables(
        null,
        null,
        null,
        null
    )

    editTextName.requestFocus()
}

完整重設可以避免上一筆資料的性別、結果圖片或計算內容殘留在畫面上。最後使用 requestFocus() 將游標移回姓名欄位,讓使用者可以直接開始輸入下一筆資料。參考資料:Android Developers-View.requestFocus


十八、延伸練習:剪刀、石頭、布

這次上傳的素材還包含剪刀、石頭、布圖片,可以運用今天學到的 RadioGroupRadioButtonImageViewButtonwhen,製作猜拳遊戲。

布
石頭
剪刀

遊戲流程可以設計成:

  1. 使用者選擇剪刀、石頭或布。
  2. 按下「開始猜拳」。
  3. 電腦使用亂數產生 0..2
  4. 將亂數對應到剪刀、石頭或布。
  5. 比較雙方選擇。
  6. TextView 顯示勝負結果。
  7. ImageView 顯示電腦出拳圖片。

Kotlin 可以使用 (0..2).random() 產生三種結果。參考資料:Kotlin-Random

val computerChoice = (0..2).random()

val computerText =
    when (computerChoice) {
        0 -> "剪刀"
        1 -> "石頭"
        else -> "布"
    }

猜拳勝負可以使用 when 判斷:

val result =
    when {
        playerChoice == computerChoice ->
            "平手"

        playerChoice == 0 &&
            computerChoice == 2 ->
            "玩家獲勝"

        playerChoice == 1 &&
            computerChoice == 0 ->
            "玩家獲勝"

        playerChoice == 2 &&
            computerChoice == 1 ->
            "玩家獲勝"

        else ->
            "電腦獲勝"
    }

這個延伸練習能把今天的單選按鈕、圖片切換、亂數與條件判斷整合成一個小型遊戲。參考資料:Kotlin-Conditions and loops


十九、Android Emulator 無法使用時的降級方法

若新版 Android Emulator 在特定電腦出現相容性問題,可以依照參考文件,暫時改用官方封存的 Emulator 版本,例如 35.5.10 Stable。不過降級屬於疑難排解手段,應先確認 SDK Tools、虛擬化功能、顯示卡驅動程式及 AVD 設定。參考資料:Android Developers-Emulator download archives

步驟一:確認 Android SDK 路徑

在 Android Studio 開啟:

Tools
→ SDK Manager
→ Android SDK Location

Windows 常見位置:

C:\Users\User\AppData\Local\Android\Sdk

實際路徑會依 Windows 帳號名稱與自訂安裝位置不同,應以 Android Studio 顯示的 Android SDK Location 為準。參考資料:Android Developers-Update IDE and SDK tools

步驟二:關閉相關程式

操作檔案前,先關閉:

Android Studio
Android Emulator
adb

至少要確定模擬器已完全關閉,避免 emulator 資料夾中的檔案仍被程序占用。參考資料:Android Developers-Emulator troubleshooting

步驟三:下載指定 Emulator 版本

官方封存下載頁:

Android Emulator Download Archives

依作業系統下載對應版本,例如:

Android Emulator 35.5.10 Stable
Windows

只應從 Android Developers 官方網站下載 Emulator,不建議使用來源不明的壓縮檔。參考資料:Android Developers-Emulator archive

步驟四:備份原本的 emulator 資料夾

進入 Android SDK 目錄:

C:\Users\User\AppData\Local\Android\Sdk

將原本的:

emulator

更名為:

emulator_bck

保留備份後,若降級版本無法使用,還能刪除新資料夾並將 emulator_bck 改回 emulator。參考資料:Android Developers-Manually install an Emulator version

步驟五:解壓縮下載的版本

將下載的 Emulator 壓縮檔解壓縮,確認解壓縮後的資料夾名稱為:

emulator

再將整個資料夾放入:

C:\Users\User\AppData\Local\Android\Sdk

完成後應形成:

C:\Users\User\AppData\Local\Android\Sdk\emulator

不要變成雙層路徑:

C:\Users\User\AppData\Local\Android\Sdk\emulator\emulator

參考資料:Android Developers-Emulator download archives

步驟六:處理 package.xml

依照上傳文件的做法,可將備份資料夾:

emulator_bck\package.xml

複製到新的:

emulator\package.xml

但官方說明指出,手動安裝特定 Emulator 版本時,需要讓 package.xml 內記錄的 Emulator 版本與實際安裝版本一致。因此不能只複製後完全不檢查版本內容,否則 Android Studio 可能誤判已安裝版本。參考資料:Android Developers-Manually install a select Emulator version

步驟七:重新開啟 Android Studio

重新開啟 Android Studio 後,進入:

Tools
→ Device Manager

再啟動原本的 Pixel 6 AVD。如果仍無法啟動,可以嘗試:

Cold Boot Now

若 AVD 快取已損壞,再考慮:

Wipe Data

Wipe Data 會清除模擬器中的 App 與測試資料,執行前應先確認模擬器內沒有需要保留的內容。參考資料:Android Developers-Create and manage virtual devices


二十、今天三個範例的比較

範例 主要元件 練習重點
花朵選擇 RadioGroupRadioButton 單選項目
附加選項 CheckBox 多選項目
BMI 計算器 TextInputEditTextRadioGroupButtonTextView 表單驗證、計算與圖片顯示
猜拳延伸 RadioButtonImageView、亂數 條件判斷與遊戲流程
模擬器排錯 Android Emulator 開發環境維護

參考資料:Android Developers-Views UI


二十一、今天學到的重點

今天透過選項表單和 BMI 計算器,完成了以下 Android 開發練習:

  1. 使用 Layout Editor 設計 Android XML 畫面。
  2. 使用 LinearLayout 水平或垂直排列元件。
  3. 使用 RadioGroup 管理互斥選項。
  4. 使用 checkedRadioButtonId 取得單選結果。
  5. 使用 CheckBox.isChecked 取得複選結果。
  6. 使用 TextInputEditText 接收使用者輸入。
  7. 使用 trim() 移除輸入內容頭尾空白。
  8. 使用 toIntOrNull()toDoubleOrNull() 安全轉換數字。
  9. 使用 Toast 顯示輸入錯誤。
  10. 使用函式拆分初始化、計算、分類與圖片更新。
  11. 使用 BMI 公式計算身體質量指數。
  12. 使用台灣成人 BMI 標準判斷體位。
  13. 使用 ContextCompat.getDrawable() 取得圖片。
  14. 使用 setBounds() 設定 Drawable 尺寸。
  15. 使用 setCompoundDrawables() 在文字旁顯示圖片。
  16. 使用 clearCheck() 清除 RadioGroup 選擇。
  17. 使用官方 Emulator Archive 排查版本相容性問題。

參考資料:Android Developers-UI components衛生福利部國民健康署-成人健康體位標準


結語

今天的 BMI App 已經具備一個實用表單的基本流程:接收資料、驗證輸入、執行計算、判斷結果,再使用文字與圖片回應使用者。這些技巧不只適用於 BMI,也能延伸到註冊表單、問卷、購物選項、健康紀錄及個人資料設定。參考資料:Android Developers-Text fields

之後有篇幅可以再繼續學習 SpinnerSwitch、等 與日期時間選擇器,讓表單支援下拉選單、開關、數值範圍及日期輸入。參考資料:Android Developers-Common widgets


上一篇
正式進入 Android!從 MainActivity 看懂 App 啟動與 Edge-to-Edge 畫面
下一篇
打造 BMI 表單介面進美化——TextInputLayout、ConstraintLayout 與 Drawable 圖文實戰
系列文
Android 鐵人賽: 天命最高 - 陪伴大家一步步打造屬於自己的app21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言